Skip to content
created by Aha00aAha00a at 2026-05-26
last modified by Aha00aAha00a at 2026-06-09
revision: 2

Dev WebSocket 구현 정리

이 문서는 현재 AhaWiki에 구현된 WebSocket 기능(페이지 뷰 화면의 실시간 커서 공유 + 페이지 갱신 알림)을 코드 기준으로 정리합니다.

1. 엔드포인트와 연결 URL

  • 서버 라우트:
    • GET /ws/w/*nameEncodedcontrollers.Wiki.watch(nameEncoded: String).
  • 클라이언트는 위키 뷰 페이지에서 현재 경로(/w/...)를 /ws/w/...로 치환해 WebSocket URL을 만들고,
    • httpswss://, 그 외에는 ws://를 사용합니다.

즉, 사용자가 문서 보기 화면에 진입하면 브라우저 JS가 해당 문서용 WebSocket에 자동 연결을 시도합니다.

2. 서버 측 구조 (controllers.Wiki)

2.1. Room 키

  • 페이지 단위 room 키는 wiki:<siteId>:<pageId> 형식입니다.
  • 멀티 사이트 환경에서 siteId + pageId 조합으로 같은 이름의 문서를 분리합니다.

2.2. 메모리 허브: PageCursorHub

PageCursorHub는 서버 메모리 내 구독자 맵을 가지고 브로드캐스트를 수행합니다.

  • 저장 구조
    • subscribers: TrieMap[roomKey, TrieMap[connectionId, PageSubscriber]]
    • PageSubscriberqueue(outbound queue)와 saveSenderId를 가짐.
  • 동작
    • subscribe: room에 연결 추가
    • unsubscribe: 연결 제거 및 queue complete
    • broadcast: 발신자(connectionId) 제외하고 메시지 전송
    • setSaveSenderId: 연결별 저장용 식별자 저장
    • broadcastPageUpdated: 동일 saveSenderId 연결은 제외하고 page.updated 전송

참고: 이 허브는 프로세스 메모리 기반이라, 다중 인스턴스(서버 여러 대) 환경에서는 인스턴스 간 동기화가 없습니다.

2.3. 핸드셰이크/권한 확인 (watch)

watch(nameEncoded)에서 수행하는 핵심 로직:

  • nameEncoded를 decode해 페이지명 복원
  • 현재 host 기준 사이트(Site) 조회
  • 페이지 최신 리비전 컨텐츠를 기준으로 읽기 권한(WikiPermission.isReadable) 검사
  • 권한 없으면 Forbidden("Permission denied.") 반환
  • 권한 있으면 WebSocket Flow를 생성

이로 인해 읽기 권한이 없는 페이지는 소켓 연결 자체가 거부됩니다.

2.4. 메시지 처리 (서버가 받는 타입)

서버 sink는 inbound JSON의 type에 따라 아래를 처리합니다.

2.4.1. cursor.move

  • 입력: { type: "cursor.move", x, y }
  • x, y는 숫자로 읽고 각각 [0.0, 1.0] 범위로 clamp
  • 서버가 재포장하여 room에 브로드캐스트:
    • type, siteId, pageId, senderId, x, y, ts

2.4.2. cursor.hello

  • 입력: { type: "cursor.hello", saveSenderId? }
  • saveSenderId를 연결 상태에 저장
  • 현재 연결의 senderId, 사용자 nickname, profileImageUrl을 담은 hello를 타 사용자에게 브로드캐스트

2.4.3. 그 외 타입

  • 무시 (no-op)

2.5. 연결 시/종료 시 동작

  • 연결 직후(mapMaterializedValue)
    • room 구독 등록
    • 자기 자신 queue에 cursor.hello 1회 push
    • 타 구독자에게도 cursor.hello 브로드캐스트
  • 연결 종료(watchTermination)
    • room에서 unsubscribe

3. 저장 시 페이지 갱신 이벤트 (page.updated)

문서 저장 API(save)에서 실제 저장 성공 후 다음 payload를 만들어 브로드캐스트합니다.

  • type: "page.updated"
  • pageName
  • revision (새 리비전)
  • editorNickname
  • dateInserted

전송 방식은 broadcastPageUpdated(roomKey, saveSenderId, payload)이며,

  • 요청 폼으로 넘어온 saveSenderId와 동일한 saveSenderId를 가진 구독자에게는 알림을 제외합니다.
  • 목적: 저장을 유발한 동일 클라이언트(혹은 동일 브라우저 탭 그룹)에게 중복 "새로고침 알림"을 줄이는 것.

4. 클라이언트 측 동작 (view.scala.html)

문서 보기 페이지 JS가 WebSocket을 담당합니다.

4.1. 연결/재연결

  • connect()에서 new WebSocket(wsUrl) 생성
  • onopensaveSenderId를 생성 후 cursor.hello 전송 (브라우저 쿠키 ID + 탭별 sessionStorage ID 조합)
  • saveSenderId브라우저 공통 ID(쿠키, 1년) + 탭 고유 ID(sessionStorage)를 결합해, 같은 브라우저의 다른 탭은 서로 다른 값이 되도록 합니다.
  • onclosereconnectDelayMs 후 재연결 시도
  • onerror/onclose는 console warn 로그 출력

4.2. 수신 메시지 처리

4.2.1. cursor.hello

  • senderId를 기준으로 상대 커서 메타(nickname, profileImageUrl) 저장
  • 본인 senderId 확정 로직 수행
  • 새 상대를 처음 인지하면 hello ack 성격으로 cursor.hello를 다시 보냄

4.2.2. cursor.move

  • upsertCursor(senderId, x, y) 호출
  • 화면 크기 기준 좌표로 변환 후 커서 DOM 갱신 대상에 반영
  • 별도 렌더 루프(requestAnimationFrame)가 보간(smoothing) 이동 처리

4.2.3. page.updated

  • revision이 유효하고, 마지막으로 본 revision과 다를 때만 처리
  • ${editorNickname} updated this page. Would you like to refresh? 토스트 노출
  • 중복 revision은 무시

4.3. 송신 메시지 처리

  • 마우스 이동 이벤트에서 50ms throttle로 cursor.move 전송
  • 좌표는 e.pageX / window.width, e.pageY / window.height로 정규화하여 [0,1] 범위로 보냄

5. 현재 프로토콜 요약

클라이언트 → 서버:

  • cursor.hello
{ "type": "cursor.hello", "saveSenderId": "..." }
  • cursor.move
{ "type": "cursor.move", "x": 0.42, "y": 0.77 }

서버 → 클라이언트:

  • cursor.hello
{ "type": "cursor.hello", "senderId": "...", "nickname": "...", "profileImageUrl": "..." }
  • cursor.move
{ "type": "cursor.move", "siteId": 1, "pageId": "Home", "senderId": "...", "x": 0.42, "y": 0.77, "ts": 1710000000000 }
  • page.updated
{ "type": "page.updated", "pageName": "Home", "revision": 12, "editorNickname": "Alice", "dateInserted": "2026-05-11T10:20:30" }

6. 운영/구조상 유의사항

  • 현재 허브는 인메모리 구현이라 서버 프로세스가 여러 개면 인스턴스 간 이벤트 공유가 되지 않습니다.
  • 큐 크기는 Source.queue[String](32, OverflowStrategy.dropHead)이므로 소비 지연 시 오래된 outbound 메시지가 버려질 수 있습니다.
    • 커서 위치 스트림 특성상 최신 상태 우선 정책으로 해석 가능합니다.
  • 읽기 권한은 소켓 연결 시점에 검사되며, 연결 후 권한 변경에 대한 즉시 강제 종료 로직은 별도로 보이지 않습니다.
  • Kanban 인터프리터 저장은 saveSenderId를 함께 전송합니다. 서버에서 409 Conflict를 반환하면 최신 revision을 재조회 후 최대 3회 자동 재시도하고, 모두 실패 시 non-blocking 토스트를 표시합니다 (페이지 강제 새로고침 없음).
  • page.updated 이벤트를 칸반 실시간 동기화에 활용합니다 — 자세한 내용은 Kanban Realtime] 참조.

7. 페이지 새로고침 알림 상세 (page.updated)

같은 페이지를 여러 사용자가 동시에 보고 있을 때, **다른 사용자의 저장**을 감지해 새로고침을 유도하는 기능입니다.

7.1. 서버 이벤트 발행

페이지 저장 성공 시점에 해당 페이지 room으로 page.updated 이벤트를 broadcast 합니다.

  • type: "page.updated"
  • pageName: 페이지 이름
  • revision: 최신 revision 번호
  • editorNickname: 저장 사용자 닉네임(가능 시)
  • dateInserted: 서버 timestamp(ISO8601)

7.2. 클라이언트 수신/처리

Wiki/view 페이지의 WebSocket onmessage에서 page.updated 이벤트를 처리합니다.

7.2.1. 공통 처리 (모든 페이지)

  • 토스트 메시지: ${editorNickname} updated this page. Would you like to refresh?
  • 액션 버튼: Refresh → 클릭 시 window.location.reload() 실행

7.2.2. 추가 처리: wiki:page.updated DOM CustomEvent 발행

page.updated 수신 후 document.dispatchEvent(new CustomEvent('wiki:page.updated', { detail: { pageName, revision, editorNickname } })) 를 발행합니다.

이를 통해 페이지에 임베드된 컴포넌트(예: Kanban)가 자신의 업데이트 로직을 독립적으로 구독할 수 있습니다.

7.2.3. Kanban 페이지에서의 추가 동작

AhaWiki.Kanban.jswiki:page.updated 이벤트를 수신하면 보드를 자동으로 갱신합니다.

  • 500ms debounce 후 GET /w/{page}?action=raw 으로 최신 원문 fetch
  • 칸반 블록 위치 재계산 → parseKanbanText
  • 3-way 병합(base / local / server) 적용 → rerenderColumns
  • 사용자 입력 보호: 뮤테이션 대기 중이면 완료 후 재적용, 모달 열린 카드는 로컬 버전 유지

자세한 내용: Kanban Realtime]

7.3. 중복 알림 방지

  • 동일 revision에 대해서는 토스트를 1회만 표시
  • 이미 떠 있는 토스트의 중복 표시 방지

7.4. 동작 시나리오 (일반 위키 페이지)

  • 사용자 A와 B가 같은 페이지를 열어둡니다.
  • A가 페이지를 저장합니다.
  • 서버가 page.updated 이벤트를 페이지 room에 전송합니다.
  • B 화면에서 새로고침 안내 토스트가 표시됩니다.
  • B가 Refresh를 누르면 페이지가 새로고침되어 최신 revision을 확인할 수 있습니다.

7.5. 동작 시나리오 (Kanban 페이지)

  • 사용자 A와 B가 같은 Kanban 페이지를 열어둡니다.
  • A가 카드를 추가·이동·수정합니다.
  • 서버가 page.updated 이벤트를 전송합니다.
  • B의 view.scala.htmlwiki:page.updated DOM CustomEvent를 발행합니다.
  • B의 AhaWiki.Kanban.js가 이벤트를 수신 → 원문 fetch → 병합 → 보드 자동 갱신.
  • B는 새로고침 없이 A의 변경이 보드에 반영됨을 확인합니다.

7.6. 운영 확인 항목

  • 저장 당사자(sender) 제외 전송 보장 확인
  • watch 권한 모델과 이벤트 수신 범위 정합성 확인
  • 토스트 UX(입력 중 방해 최소화, 모바일/데스크톱 가독성) 점검
  • 운영 환경 WebSocket 에러/재연결 로그 모니터링

8. See Also

8.2. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 42.61% Dev Page page(42:21), updated(26:1), 페이지(20:5), wiki(13:2), name(8:6), 최신(6:6), revision(5:6), url(5:6), site(5:4), 권한(7:1)
  • 33.06% Dev Telegram page(42:12), id(25:23), 페이지(20:9), 처리(14:4), save(13:5), wiki(13:5), name(8:10), url(5:13), 저장(12:2), site(5:8)
  • 30.37% Dev Kanban Realtime page(42:12), kanban(12:39), updated(26:8), id(25:6), cursor(27:2), 페이지(20:5), 서버(12:13), sender(20:1), 저장(12:8), columns(1:19)

8.3. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+